package bancosys.tec.persist.column;

import static jmine.tec.persist.PersistMessages.VALIDATION_ERROR_SIZE_LARGER_THAN_ALLOWED;

import java.lang.annotation.Annotation;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

import javax.persistence.Column;

import jmine.tec.annotations.Unmodifiable;
import jmine.tec.persist.PersistMessages;
import jmine.tec.utils.Tuple;
import jmine.tec.utils.UnmodifiableTuple;

import org.apache.log4j.LogManager;
import org.apache.log4j.Logger;
import org.hibernate.EntityMode;
import org.hibernate.Hibernate;
import org.hibernate.SessionFactory;
import org.hibernate.event.SaveOrUpdateEvent;
import org.hibernate.event.def.DefaultSaveOrUpdateEventListener;
import org.hibernate.metadata.ClassMetadata;
import org.hibernate.proxy.HibernateProxy;
import org.hibernate.type.AbstractLobType;
import org.hibernate.type.BlobType;
import org.hibernate.type.ClobType;
import org.hibernate.type.CustomType;
import org.hibernate.type.TextType;

import bancosys.tec.persist.exception.PersistenceException;
import bancosys.tec.utils.reflection.ReflectionUtils;
import bancosys.tec.utils.reflection.ReflectionUtilsException;

/**
 * Listener que é responsável por validar o Scale e o Precision das colunas.
 */
public class ColumnEventListener extends DefaultSaveOrUpdateEventListener {

    private static final Logger LOG = LogManager.getLogger(ColumnEventListener.class);

    private final Map<Class<?>, Collection<FieldMetaData>> cache = new ConcurrentHashMap<Class<?>, Collection<FieldMetaData>>();

    private SessionFactory sessionFactory;

    /**
     * {@inheritDoc}
     */
    @Override
    public void onSaveOrUpdate(SaveOrUpdateEvent event) {
        this.validateProperties(event);
        super.onSaveOrUpdate(event);
    }

    /**
     * Valida que os valores inseridos em cada campo estão de acordo com as propriedades anotadas para {@link Column}.
     * 
     * @param event o evento que contém o objeto sendo atualizado/inserido.
     */

    private void validateProperties(SaveOrUpdateEvent event) {
        sessionFactory = event.getSession().getSessionFactory();
        Object object = event.getObject();
        Collection<FieldMetaData> fields = this.findMethodsToSetColumn(object);
        if (fields.isEmpty()) {
            return;
        }

        for (FieldMetaData field : fields) {
            Object value = null;
            value = field.get(object);
            if (value == null || field.isLob()) {
                continue;
            }
            if (value instanceof Number) {
                this.validateNumbers(field, (Number) value, object);
            }
            if (value instanceof String) {
                this.validateLength(field, (String) value, object);
            }
        }

    }

    /**
     * Valida que o valor do campo passado é menor que o permitido, de acordo com a anotação de length do campo.
     * 
     * @param field o campo
     * @param value o valor sendo inserido
     * @param bean o bean da classe
     */
    private void validateLength(FieldMetaData field, String value, Object bean) {
        int length = field.getColumn().length();
        if (value.length() > length) {
            throw new PersistenceException(VALIDATION_ERROR_SIZE_LARGER_THAN_ALLOWED.create(field.getPropertyName(bean), length));
        }
    }

    /**
     * Valida as propriedades de tamanho máximo e número máximo de casas decimais dos campos numéricos.
     * 
     * @param field o campo
     * @param value o valor sendo inserido
     * @param bean o bean da classe
     */
    private void validateNumbers(FieldMetaData field, Number value, Object bean) {
        if (value instanceof BigDecimal) {
            this.validatePrecision(field, (BigDecimal) value, bean);
            this.validateScale(field, (BigDecimal) value, bean);
            return;
        }
        if (value instanceof Double) {
            this.validatePrecision(field, new BigDecimal(value.toString()), bean);
            this.validateScale(field, new BigDecimal(value.toString()), bean);
            return;
        }
        
        this.validatePrecision(field, new BigDecimal(value.toString()), bean);
    }

    /**
     * Valida que o valor do campo passado não tem mais casas decimais que o permitido de acordo com a anotação scale do campo.
     * 
     * @param field o campo
     * @param valor o valor sendo inserido
     * @param bean o bean da classe
     */
    private void validateScale(FieldMetaData field, BigDecimal valor, Object bean) {
        int precision = field.getColumn().precision();
        if (precision == 0) {
            return; // não tem anotação, não executar a validação. Não posso testar com o scale == 0 , pois poderia ter anotado como:
                    // precision 10, scale 0
        }
        int scale = field.getColumn().scale();
        if (valor.scale() > scale) {
            throw new PersistenceException(PersistMessages.DECIMAL_DIGITS_LARGER_THAN_ALLOWED_FOR_ENTITY.create(valor,
                    field.getPropertyName(bean), field.getEntityName(bean), scale, valor.scale()));
        }
    }

    /**
     * Valida que o valor do campo passado não tem tamanho maior do que o permitido de acordo com a anotação precision do campo.
     * 
     * @param field o campo
     * @param valor o valor sendo inserido
     * @param bean o bean da classe
     */
    private void validatePrecision(FieldMetaData field, BigDecimal valor, Object bean) {
        int precision = field.getColumn().precision();
        if (precision == 0) {
            return; // não tem anotação, não executar a validação
        }
        int integerPartAnnotation = field.getColumn().precision() - field.getColumn().scale();
        int integerPartValue = valor.precision() - valor.scale();
        if (integerPartValue > integerPartAnnotation) {
            throw new PersistenceException(PersistMessages.INTEGER_PART_LARGER_THAN_ALLOWED_FOR_ENTITY.create(valor,
                    field.getPropertyName(bean), field.getEntityName(bean), integerPartAnnotation, integerPartValue));
        }
    }

    /**
     * Busca todos os campos da classe que possuem a anotação {@link Column}.
     * 
     * @param bean o bean da classe
     * @return Metadados de todos os campos da classe que possuem a anotação {@link Column}
     */
    private Collection<FieldMetaData> findMethodsToSetColumn(Object bean) {
        // Verificar se essa classe ja foi utilizada alguma vez
        Class<?> clazz = bean.getClass();
        if (bean instanceof HibernateProxy) {
            Hibernate.initialize(bean);
            clazz = ((HibernateProxy) bean).getHibernateLazyInitializer().getImplementation().getClass();
        }

        Collection<FieldMetaData> fieldsFromCache = this.cache.get(clazz);
        if (fieldsFromCache != null) {
            return fieldsFromCache;
        }

        ClassMetadata metadata = sessionFactory.getClassMetadata(clazz);

        // Extrair quais são os campos que devem ser atualizados
        Collection<Tuple<String, Column>> tuples = findMethodsWithAnnotation(clazz, Column.class, metadata);

        Collection<FieldMetaData> fields = new LinkedList<FieldMetaData>();
        for (Tuple<String, Column> tuple : tuples) {
            // Criar uma nova instancia de metadados
            FieldMetaData fieldMetaData = new FieldMetaData(tuple.tail(), metadata, tuple.head());
            fields.add(fieldMetaData);
        }

        // Salvar os dados no cache.
        this.cache.put(clazz, fields);

        return fields;
    }

    /**
     * Procura todos os metodos com as anotacoes passadas.
     * 
     * @param <E> o tipo da anotacao
     * @param declaringClass a classe que declara os metodos
     * @param annotation o tipo da anotacao
     * @param metadata o metadata da classe
     * @return {@link Collection} de {@link Method}
     */
    public static <E extends Annotation> Collection<Tuple<String, E>> findMethodsWithAnnotation(Class<?> declaringClass,
            Class<E> annotation, ClassMetadata metadata) {
        List<Tuple<String, E>> list = new ArrayList<Tuple<String, E>>();
        for (String method :  metadata.getPropertyNames()) {
            E value = null;
            try {
                value = ReflectionUtils.getReadAccessor(declaringClass, method).getAnnotation(annotation);
            } catch (ReflectionUtilsException e) {
                // acontece quando o metodo devolvido pelo metadata é private ou quando o método não é encontrado
                LOG.trace(declaringClass, e);
                continue;
            }
            if (value != null) {
                list.add(UnmodifiableTuple.newTuple(method, value));
            }
        }
        return list;
    }
    
    /**
     * @author Rafael Volpato
     */
    @Unmodifiable
    private static class FieldMetaData {

        private final boolean isLob;

        private final Column column;

        private final ClassMetadata metadata;

        private final String propertyName;

        /**
         * Construtor.
         * 
         * @param column a coluna
         * @param metadata o metadata da entidade
         * @param propertyName o nome da propriedade
         */
        public FieldMetaData(Column column, ClassMetadata metadata, String propertyName) {
            this.column = column;
            this.isLob =
                    (metadata.getPropertyType(propertyName) instanceof AbstractLobType)
                            || (metadata.getPropertyType(propertyName) instanceof CustomType && metadata.getPropertyType(propertyName)
                                    .getName().contains("StringClobType"))
                            || (metadata.getPropertyType(propertyName) instanceof BlobType)
                            || (metadata.getPropertyType(propertyName) instanceof ClobType)
                            || (metadata.getPropertyType(propertyName) instanceof TextType);
            this.metadata = metadata;
            this.propertyName = propertyName;
        }

        /**
         * @return the uniqueGeneratedValue
         */
        public final Column getColumn() {
            return this.column;
        }

        /**
         * @return the isLob
         */
        public final boolean isLob() {
            return this.isLob;
        }

        /**
         * Retorna o valor da propriedade do bean
         * 
         * @param bean o bean da classe
         * @return Object valor da propriedade
         */
        public final Object get(Object bean) {
            return metadata.getPropertyValue(bean, propertyName, EntityMode.POJO);
        }

        /**
         * Retorna o nome da entidade
         * 
         * @param bean o bean da classe
         * @return String nome da entidade
         */
        public String getEntityName(Object bean) {
            return bean.getClass().getSimpleName();
        }

        /**
         * Imprime o nome da propriedade
         * 
         * @param bean o bean da classe
         * @return String nome da propriedade
         */
        public String getPropertyName(Object bean) {

            return ReflectionUtils.getReadAccessor(bean.getClass(), propertyName).getName();
        }

        /**
         * toString para imprimir nome da entidade e da propriedade
         * 
         * @return String nome da entidade e da propriedade
         */
        @Override
        public String toString() {
            return metadata.getEntityName() + "." + propertyName;
        }
    }

}
